Skip to content

ci(i18n): let a protected term be satisfied by a declared equivalent form - #1966

Merged
zksquirrel merged 2 commits into
ZecHub:mainfrom
bloxster:i18n/plural-satisfied-by-singular
Aug 17, 2026
Merged

ci(i18n): let a protected term be satisfied by a declared equivalent form#1966
zksquirrel merged 2 commits into
ZecHub:mainfrom
bloxster:i18n/plural-satisfied-by-singular

Conversation

@bloxster

Copy link
Copy Markdown
Contributor

Why

Some protected terms are the same word in two shapes — a singular and its plural. Whether an English loanword takes an -s is a decision each language makes for itself, not one English makes for it. Italian writes "gli Unified Address", because Italian does not pluralize borrowed nouns; Japanese, Korean and Chinese do not inflect plurals at all.

Demanding one exact shape therefore forces English grammar into 18 languages that do not share it, to assert a term the other shape already carries.

This is not hypothetical. ZK-SNARKs is protected and its singular was not, so on all four English pages using it, ja/ko/zh/it carry the English plural verbatim. They had no choice — the gate required it.

What this does

A translation may now satisfy a protected term with any form declared equivalent to it, via a new optional key in translation/protected-terms.json:

"equivalentForms": [
  ["ZK-SNARK", "ZK-SNARKs"]
]

A group means "these are one term", so membership is symmetric — any member asserts it. The source side still tests the exact term, because the English page really does use that form; only the translation side accepts an equivalent.

Declared, never derived

An earlier draft inferred the pairing by stripping a trailing s/es. Two reviewers independently rejected that, and they were right:

  • it makes Argos interchangeable with Argo, and zec.rocks with zec.rock — different things that merely look related;
  • it breaks on acronyms (CSSCS);
  • it silently misses irregular plurals such as Technologies / Technology;
  • and it got its own motivating case wrong, deriving Unified Addresse from Unified Addresses, so the relaxation would never have fired for the term that prompted it.

A maintainer now states which forms are one word; the checker never guesses.

Every malformed declaration is fatal: a member absent from preserveVerbatim, a term in two groups, a duplicate within a group, a group of fewer than two, a non-array, a blank or whitespace-padded entry. A group that was quietly ignored would weaken this gate in the way least likely to be noticed — and a blank entry is the one shape that could do real damage, since a term of " " matches nearly every page.

It also closes a false negative

satisfiedIn is applied to classify()'s baseline test, and that tightens the gate rather than merely preserving it. If a base translation carries only the singular and a change strips it, the previous exact-match baseline made violatedAtBase true — bucketing the result pre-existing and exiting 0, silently passing a change that removed the last protected form. It is now correctly introduced, exit 1.

Base content is judged by the base declaration, read from git at the merge base, not by this change's config. Otherwise a PR that removed an equivalence group could never fail the gate: the base would look as though it had already been violating. A base config that cannot be read or parsed falls back to the head declaration rather than failing a PR over history it did not write.

Verification

check result
unit tests 51 pass / 0 fail (38 existing + 13 new)
whole-tree violations 72 before and after
scoped gate on this branch exit 0
remove the group and strip the last form exit 1, reported as introduced
malformed declaration fatal, exit 1, names the offending entry

The logic lives in translation/lib/term-forms.mjs — pure, no filesystem, process or clock — which puts its tests where the existing hash-lib-tests job already globs (translation/lib/*.test.mjs), so no workflow change is needed. They cover the declared pair in both directions, the -es case the old derivation got wrong, look-alikes that must not pair, and each validation failure.

termRegExp now memoizes; it was recompiling per (page, locale, term).

Note on scope

This changes nothing on the current corpus — 72 violations before and after — because the harm already landed: the locales were forced to carry the plural, so no violations remain to clear. The value is preventive, and it is why the companion PR adds ZK-SNARK rather than removing ZK-SNARKs.

Merge order

Stacked on #1965, which adds the ZK-SNARK singular that the declared group refers to. Merge that first; the diff here collapses to the checker change once it lands.

Known limitation

A PR that edits equivalentForms while also changing translations is judged partly by its own declaration, so removing a group cannot itself fail the gate in every case. Reviewers should treat a change to equivalentForms as needing a whole-tree audit (node scripts/check-protected-terms.mjs with no --base) rather than trusting the scoped result alone.

Three gaps found while auditing this list against the categorized list in
the frontend repo.

1. Key and address compounds are only half protected. `Unified Address` and
   `Viewing Key` are enforced, the other expanded forms are not, so a
   translation can localize them and nothing complains:

     Full Viewing Key       5 English pages, 0 translations missing it
     Incoming Viewing Key   2 English pages, 0 translations missing it

2. The check is case-sensitive, so a term protected in one casing is
   unprotected in the other. The list carries `Free2Z`, `Zcash.Me`,
   `MetaMask Snap` and `Zingo`, while the variants that actually appear in
   the wiki went unenforced:

     Zcash.me    1 English page,  0 translations missing it
     Zingo!     20 English pages, 0 translations missing it
     ZGo        27 English pages, 0 translations missing it
     Zgo         9 English pages, 0 translations missing it

   Same trap as `Zodl` vs `ZODL`, which is still open.

3. `ZK-SNARKs` is enforced but its singular is not:

     ZK-SNARK    1 English page,  0 translations missing it

All seven are free: every existing translation already keeps them verbatim,
so this locks in current behaviour and prevents a future regression.
Verified — the whole-tree audit reports 72 violations both before and after,
and none names a newly added term. (Those 72 are pre-existing drift from
ZecHub#1948 and ZecHub#1957, cleared by the pending backfill/re-sync PR.)

WHAT IS NOT HERE, AND WHY IT SHOULD NOT BE ADDED LATER EITHER

An earlier draft of this change listed `Unified Addresses` as merely
expensive — 94 violations, 113 after the pending re-sync. Measuring what
those violations actually are changed the conclusion: protecting an English
PLURAL is wrong in principle, not just costly.

Of the 113, 70% already carry the protected singular `Unified Address`.
They did not lose the concept; they declined to pluralize an English
loanword. English "for inspecting Unified Addresses" becomes Italian "per
ispezionare gli Unified Address", which is correct Italian — Italian does
not add -s to English loanwords. Japanese writes `Unified Addressを調べる`
because Japanese does not inflect plurals at all, and 23 of the 113 sit in
ja/ko/zh, none of which do.

Enforcing the plural would push a grammatical error into Italian, French,
Spanish and others, and a meaningless string into three more, to satisfy a
check the singular already satisfies conceptually.

The principle: protect singulars, not plurals. Languages differ in whether
and how they pluralize borrowed nouns, so a protected plural imposes English
grammar on all 18 locales.

This is not hypothetical. `ZK-SNARKs` is enforced today, and on all four
English pages that use it, ja/ko/zh/it carry the English plural verbatim —
they had no choice. A follow-up will teach the checker that a plural is
satisfied by its protected singular and retire the plural entries; this
change adds the singular so that follow-up has something to fall back to.

Also still held back, each priced against both main and the pending re-sync
tree, because adding them creates violations no queued sync repairs (the
terms list is not part of staleness detection, so the debt is invisible to
the dashboard and unreachable by the sync agent): TEX Address 39,
Dev Fund 25/29, Diversified Address 14, Spending Key 7, Metamask Snap 2,
zk-SNARK 2, Free2z 1.

Skipped entirely: `Unified Viewing Key` and `Outgoing Viewing Key` are in
the categorized list but appear on no English page.
…form

Some protected terms are the same word in two shapes — a singular and its
plural. Whether an English loanword takes an -s is a decision each language
makes for itself, not one English makes for it: Italian writes "gli Unified
Address" because Italian does not pluralize borrowed nouns, and Japanese,
Korean and Chinese do not inflect plurals at all. Demanding one exact shape
forces English grammar into 18 languages that do not share it, to assert a
term the other shape already carries.

Not hypothetical: `ZK-SNARKs` is protected and its singular was not, so on
all four English pages using it, ja/ko/zh/it carry the English plural
verbatim. They had no choice — the gate required it.

A translation may now satisfy a term with any form DECLARED equivalent to it,
via a new optional `equivalentForms` key:

  "equivalentForms": [
    ["ZK-SNARK", "ZK-SNARKs"]
  ]

The pairing is declared, never inferred from spelling. An earlier draft
derived it by stripping a trailing "s"/"es", which two reviewers independently
rejected: that makes `Argos` interchangeable with `Argo` and `zec.rocks` with
`zec.rock` — different things that merely look related — breaks on acronyms
(`CSS` -> `CS`), and silently misses irregular plurals like `Technologies` /
`Technology`. It also got its own motivating case wrong, deriving "Unified
Addresse" from "Unified Addresses". A maintainer states which forms are one
word; the checker never guesses.

Membership is symmetric because a group asserts "these are one term", so any
member satisfies it. That is now a declared property rather than an accident
of the derivation.

The source side still tests the exact term — the English page really does use
that form. Only the translation side accepts an equivalent.

Every malformed declaration is fatal: a member absent from preserveVerbatim,
a term in two groups, a duplicate, a group of one, a non-array. A group that
was quietly ignored would weaken this gate in the way least likely to be
noticed.

`satisfiedIn` is applied to classify()'s baseline test as well, and that is
load-bearing in the opposite direction from the obvious one: it TIGHTENS the
gate. If a base translation carries only the singular and a change strips it,
the previous exact-match baseline made violatedAtBase true, bucketing the
result "pre-existing" and exiting 0 — silently passing a change that removed
the last protected form. It is now correctly "introduced", exit 1.

Logic lives in translation/lib/term-forms.mjs (pure, no fs/process/clock) so
the existing hash-lib-tests job picks up its 11 unit tests with no workflow
change: the declared pair in both directions, the -es case the derivation got
wrong, look-alikes that must NOT pair, and each validation failure.

termRegExp now memoizes; it was recompiling per (page, locale, term).

Note this changes NOTHING on the current corpus — 72 violations before and
after — because the harm already landed: the locales were forced to carry the
plural, so no violations remain to clear. The value is preventive, and it is
why the companion terms PR adds `ZK-SNARK` rather than removing `ZK-SNARKs`.
@zksquirrel
zksquirrel merged commit 2e43f0e into ZecHub:main Aug 17, 2026
4 checks passed
zksquirrel pushed a commit that referenced this pull request Aug 17, 2026
The doc still described a single-locale pilot edited by hand. Reality is 18
locales and 3,654 translated pages maintained by an automated sync pipeline
with a provenance manifest and four blocking gates, none of which it mentioned.

A contributor following it would hand-edit a translated page, leave
sync-state.json untouched, and fail manifest-invariants with no idea why — or
worse, follow the CI hint to `edited: true` and silently remove the page from
automated sync forever.

What changed:

- state the real scope (18 locales, 203 curated pages) and the engine split,
  noting that a manifest entry records what produced that page and can differ
  from the locale's standing assignment;
- describe the sync pipeline and point at translation/README-sync.md as
  required reading before touching translations/;
- give the correct hand-edit procedure: record the pass in `tool`, and treat
  `edited: true` as the permanent opt-out it is, not as a way to log an edit;
- warn against bumping `src` without changing the file (no escape hatch);
- explain how to add a translation for a page that has none, including
  computing `src` with the same normalize-hash module the gate uses, since
  that field cannot be written by hand and a wrong one declares a freshness
  that does not exist;
- document declared equivalent forms, and why the pairing is never derived;
- state the plural rule: protect the singular, pluralize naturally;
- document the first-use gloss, with the codename exclusion, marked explicitly
  as editorial rather than a gate;
- name both term lists, identify the CI-enforced one, and record the drift;
- list the four gates with locally-runnable commands, state that they run on
  every PR with no path filter and why, and explain what --base scoping does
  and does not fail on;
- split "adding a new language" by repository, since two of its five steps are
  frontend files that do not exist here.

Reviewed by two independent adversarial passes, both of which blocked an
earlier revision on real defects:

- it named `Blossom` as a protected codename. It is not in preserveVerbatim at
  all — the name came from the frontend's categorized copy, which is the very
  drift this revision documents. Every term the doc now presents as protected
  was checked against translation/protected-terms.json.
- "adding a new language" listed the manifest step last and framed it as
  optional follow-up, so following the steps in order produced a locale with
  translated files and no provenance: a hard failure of a required check,
  reported once for the locale and once per file. The manifest is now a
  prerequisite, not a postscript.
- that same step said "then re-seed the manifest". The seeder is a whole-corpus
  operator tool; on a manifest holding real provenance it refuses and asks for
  --force, and --force erases diff history and resets every `edited: true` to
  false. No gate catches that, because no translated file changes. The doc now
  tells contributors not to run it, and says why.
- it claimed the gates run only on pull requests touching translations. The
  workflow has no path filter, deliberately, so that required checks always
  report a status instead of stranding unrelated PRs at "Expected".
- claims about the frontend repo that cannot be validated from this one are
  now marked as such.

Merge after #1966, which adds the `equivalentForms` config this documents.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants